Frontend State Management Standard
Overview
This document defines the standard pattern for managing async state (loading, data, error) in React components. Consistent state management improves code quality, reduces bugs, and enhances user experience.
Standard Pattern
State Structure
All async state MUST follow this structure:
{
data: T | null
isLoading: boolean
error: Error | null
}Hook Usage
**Location**: src/hooks/useAsyncState.ts
**Import**:
import { useAsyncState } from '@/hooks/useAsyncState'Implementation Examples
Basic Usage
// ✅ GOOD: Using useAsyncState hook
import { useAsyncState } from '@/hooks/useAsyncState'
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error, execute } = useAsyncState(
async () => fetchUser(userId)
)
useEffect(() => {
execute()
}, [userId, execute])
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
if (!data) return <div>No data</div>
return <div>{data.name}</div>
}Auto-Execute on Mount
// ✅ GOOD: Auto-execute with useAsyncStateWithExecute
import { useAsyncStateWithExecute } from '@/hooks/useAsyncState'
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error, refresh } = useAsyncStateWithExecute(
async () => fetchUser(userId),
[userId] // Dependencies to re-fetch
)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
if (!data) return <div>No data</div>
return (
<div>
<h1>{data.name}</h1>
<button onClick={refresh}>Refresh</button>
</div>
)
}Multiple States
// ✅ GOOD: Multiple related states
function Dashboard({ userId }: { userId: string }) {
const users = useAsyncState(() => fetchUsers(), [])
const posts = useAsyncState(() => fetchPosts(), [])
useEffect(() => {
users.execute()
posts.execute()
}, [])
if (users.isLoading || posts.isLoading) return <div>Loading...</div>
return (
<div>
<UsersList data={users.data} />
<PostsList data={posts.data} />
</div>
)
}Before vs After
Before (Inconsistent)
// ❌ BAD: Inconsistent naming
const [loading, setLoading] = useState(false)
const [user, setUser] = useState(null)
const [err, setErr] = useState(null)
// ❌ BAD: Different order
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
// ❌ BAD: Multiple useState hooks
const [isLoading, setIsLoading] = useState(false)
const [data, setData] = useState(null)
const [error, setError] = useState(null)After (Standard)
// ✅ GOOD: Single hook with standard naming
const { data, isLoading, error } = useAsyncState(fetchData)
// ✅ GOOD: Destructure in consistent order
const [data, isLoading, error] = extractState(useAsyncState(fetchData))Loading States
Skeleton Loading
function UserList() {
const { data, isLoading, error } = useAsyncState(fetchUsers)
if (isLoading) {
return <UserListSkeleton />
}
if (error) {
return <ErrorMessage error={error} />
}
return <UserList data={data} />
}Inline Loading
function UserCard({ userId }: { userId: string }) {
const { data, isLoading } = useAsyncState(() => fetchUser(userId))
return (
<div className="user-card">
{isLoading ? (
<div className="skeleton">Loading...</div>
) : (
<div>{data?.name}</div>
)}
</div>
)
}Error Handling
Display Errors
function UserList() {
const { data, isLoading, error } = useAsyncState(fetchUsers)
if (error) {
return (
<div className="error-message">
<h3>Failed to load users</h3>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
)
}
return <div>{data?.map(user => <UserCard key={user.id} user={user} />)}</div>
}Retry Logic
function UserList() {
const { data, isLoading, error, execute } = useAsyncState(fetchUsers)
useEffect(() => {
execute()
}, [])
if (error) {
return (
<div className="error-message">
<p>Failed to load: {error.message}</p>
<button onClick={() => execute()}>Retry</button>
</div>
)
}
return <div>{data?.map(user => <UserCard key={user.id} user={user} />)}</div>
}Helper Functions
Check State
import { is_loading, has_data, has_error, get_state_status } from '@/hooks/useAsyncState'
function UserList() {
const state = useAsyncState(fetchUsers)
if (is_loading(state)) return <div>Loading...</div>
if (has_error(state)) return <div>Error: {state.error.message}</div>
if (has_data(state)) return <div>{state.data.length} users</div>
return <div>No data</div>
}Combine States
import { combineStates } from '@/hooks/useAsyncState'
function Dashboard() {
const users = useAsyncState(fetchUsers)
const posts = useAsyncState(fetchPosts)
const combined = combineStates([users, posts])
if (combined.isLoading) return <div>Loading...</div>
if (combined.error) return <div>Error: {combined.error.message}</div>
return (
<div>
<UserList data={users.data} />
<PostList data={posts.data} />
</div>
)
}Best Practices
1. Always Use Standard Naming
// ✅ GOOD: Standard naming
const { data, isLoading, error } = useAsyncState(fetchData)
// ❌ BAD: Non-standard naming
const { users, loading, err } = useAsyncState(fetchUsers)2. Destructure in Consistent Order
// ✅ GOOD: data, isLoading, error
const { data, isLoading, error } = useAsyncState(fetchData)
// ❌ BAD: Different order
const { error, isLoading, data } = useAsyncState(fetchData)3. Handle All States
// ✅ GOOD: Handle all states
const { data, isLoading, error } = useAsyncState(fetchData)
if (isLoading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
if (!data) return <EmptyState />
return <DataDisplay data={data} />
// ❌ BAD: Missing states
const { data, isLoading } = useAsyncState(fetchData)
if (isLoading) return <LoadingSpinner />
return <DataDisplay data={data} /> // What about error?4. Provide Loading Feedback
// ✅ GOOD: Always show loading state
const { data, isLoading } = useAsyncState(fetchData)
return (
<div>
{isLoading ? <Skeleton /> : <DataDisplay data={data} />}
</div>
)
// ❌ BAD: No loading feedback
const { data, isLoading } = useAsyncState(fetchData)
return <DataDisplay data={data} /> // User doesn't know it's loading5. Use TypeScript
// ✅ GOOD: Type the data
interface User {
id: string
name: string
}
const { data, isLoading } = useAsyncState<User>(fetchUser)
// Now data is properly typed
data?.name // TypeScript knows this is a string
// ❌ BAD: No typing
const { data } = useAsyncState(fetchUser)
data?.name // TypeScript doesn't know the typeMigration Guide
Before
function UserList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
async function load() {
setLoading(true)
setError(null)
try {
const data = await fetchUsers()
setUsers(data)
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}
load()
}, [])
if (loading) return <div>Loading...</div>
if (error) return <div>Error</div>
return <div>{users.map(u => <User key={u.id} user={u} />)}</div>
}After
function UserList() {
const { data, isLoading, error, execute } = useAsyncState(fetchUsers)
useEffect(() => {
execute()
}, [execute])
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error</div>
return <div>{data?.map(u => <User key={u.id} user={u} />)}</div>
}Testing
Test Hook Behavior
import { renderHook, waitFor } from '@testing-library/react'
import { useAsyncState } from '@/hooks/useAsyncState'
test('loads data successfully', async () => {
const { result } = renderHook(() => useAsyncState(async () => {
return await fetchData()
}))
await result.current.execute()
expect(result.current.isLoading).toBe(false)
expect(result.current.data).toEqual(expectedData)
expect(result.current.error).toBe(null)
})Test Error Handling
test('handles errors', async () => {
const { result } = renderHook(() => useAsyncState(async () => {
throw new Error('Failed')
}))
await result.current.execute()
expect(result.current.isLoading).toBe(false)
expect(result.current.error).toBeInstanceOf(Error)
expect(result.current.error?.message).toBe('Failed')
})TypeScript Types
State Type
import { AsyncState } from '@/hooks/useAsyncState'
interface User {
id: string
name: string
}
const state: AsyncState<User> = {
data: null,
isLoading: false,
error: null
}Hook Return Type
import { UseAsyncStateReturn } from '@/hooks/useAsyncState'
interface User {
id: string
name: string
}
const state: UseAsyncStateReturn<User> = useAsyncState<User>(fetchUser)Performance
Memoization
The hook automatically handles performance optimizations:
// ✅ GOOD: No need for manual memoization
const { data, isLoading } = useAsyncState(fetchData)
// ❌ BAD: Unnecessary useMemo
const state = useMemo(() => useAsyncState(fetchData), [])Cleanup
The hook handles cleanup automatically:
// ✅ GOOD: Automatic cleanup
const { execute } = useAsyncState(fetchData)
useEffect(() => {
execute()
}, [])
// Cleanup happens automatically when component unmountsReferences
- Implementation:
src/hooks/useAsyncState.ts - React Hooks: https://react.dev/reference/react
- TypeScript: https://www.typescriptlang.org/
Changelog
- 2026-02-08: Initial standard created
- 2026-02-08: Hook implementation created
- 2026-02-08: Helper functions documented